跨域和跨站的区别
涉及到 Web 安全,会有一个跨站的概念,跨站和跨域是不同的。
跨域
跨域即 cross-domain,它和同源(same-origin)相对,要求两个 URL 的协议、端口号、域名都一致才能称为同源。
跨站
跨站即 cross-site,它和同站(same-site)相对,对协议和端口号无要求,只要两个 URL 的 eTLD + 1
一致,就能称为同站。那么什么是 eTLD
呢?
eTLD
即 effective top level domain,有效顶级域名,比如 http://juejin.cn
的 eTLD
是 .cn
,http://test.org
的 eTLD
是 .org
,而 http://chorer.github.io
则是 github.io
(注意不是 .io
)。而 eTLD + 1
指的是有效顶级域名 + 二级域名,比如对于 http://juejin.cn
来说就是 juejin.cn
,对于 http://test.org
来说就是 test.org
。
**PS:**不过需要注意的是,same-site 实际上也分为两种,一种是上面定义的协议松散型 same-site,即 scheme-less same-site,在判断是否同站时并不需要考虑协议;另一种则是协议严格型 same-site,即 schemeful same-site,要求协议必须一致才能认定为是同站。
XSS
XSS 即 Cross-Site Scripting(跨站脚本攻击),指的是黑客将恶意代码注入页面中,只要打开页面,代码就会执行。XSS 攻击可能导致 Cookie 被窃取、个人信息泄露、劫持流量实现恶意跳转等。
分类
XSS 基本可以分为两类,一个是反射型 XSS(非持久型 XSS),一个是存储型 XSS(持久型 XSS)。
反射型 XSS
黑客诱导用户点击带有特殊参数的 URL,从而往页面中注入恶意代码。比如说,正常向服务器发起请求的 URL 是 http://test.com?name=jack
,服务器拿到参数 jack
之后,不做处理,直接返回一个响应 Hello jack
,HTML 是这样的:
<div>Hello jack</div>
这样当然没问题,但如果用户点击了黑客的 URL 是 http://test.com?name=<script>alert(1)</script>
,那么服务器拿到了 name
参数,如果不做处理就返回响应,HTML 会是这样的:
<div>
<script>alert(1)</script>
</div>
那么解析 HTML 的时候,实际上是会执行中间这段脚本的。alert(1)
只是一个示例 —— 这里可以是任何的脚本操作,包括通过 document.cookie
窃取用户 Cookie,通过 window.location
实现跳转等,会有很大的安全风险。
存储型 XSS
存储型 XSS 是持久的,而且风险会更大,因为恶意代码会存储到数据库中,无论哪个用户访问页面,都会被波及。比如说,黑客给某篇文章的评论区留言,写下 <script>alert(1)</script>
,之后提交表单给服务器。服务器不做任何处理,只是把留言存储到数据库中。下次无论哪位用户访问这篇文章,服务器都会从数据库中获取留言并返回给浏览器,这当然也包括了 <script>alert(1)</script>
,只要一执行就会产生弹窗,对于所有用户都是如此。同样,这里弹窗只是一个举例,它可以是任何危害到用户信息安全的脚本操作。
防御措施
1)HTML 转义
<
用于定义标签的开始,如果我们希望浏览器确实显示<
这个字符本身,而不是把它当作一个标签去解析,那么就必须对字符进行转义(escape),编写字符实体而不是字符。
同理,为了安全起见,我们不应该把 <script>alert(1)</script>
作为标签去解析,而只是希望它是一个单纯的字符串,所以可以考虑在服务端这边进行 HTML 转义:
<script>alert(1)</script>
这个转义的结果最终返回给浏览器, <script>alert(1)</script>
会作为字符串在页面上显示出来,而不再是可执行的脚本。
2)用户输入验证
转义指的是对 <
、>
等特殊字符进行转义,如果说注入的恶意脚本都是用 <script></script>
包裹的,那么 HTML 转义确实可以避免 XSS 攻击 —— 但实际上,有其他的方式可以进行脚本注入。比如某社区网站允许用户在个人资料中填写自己博客的地址,并最终作为 <a href="xxx">我的博客地址</a>
展示出来,那么别有用心的黑客就可以填入 javascript:alert(1);
,这是不会经过 HTML 转义的,因此最终黑客的博客地址是这样的:
<a href="javascript:alert(1);">这是用户博客的地址</a>
只要有人点击查看他资料中的博客地址,就会发生弹窗。
同样的,如果网站还允许用户通过填写 URL 的方式设置头像图片,并最终作为 <img src="xxx">
展示出来,那么黑客可以填入 xxx" onerror="alert(1)
,这同样是不会经过 HTML 转义的,因此最终 img
标签是这样的:
<img src="xxx" onerror="alert(1)">
这里的 src
明显是不合法的,所以会触发 error
事件,发生弹窗。
因此,单纯的 HTML 转义并不能规避所有 XSS 攻击,我们还必须对用户输入的数据进行验证。
3)CSP
CSP 即 Content Security Policy(内容安全策略),开发者提供一个白名单,告诉浏览器只能加载特定来源的代码,从而禁止某些第三方脚本的运行。
CSP 有两种使用方式:
- 服务端响应一个
content-security-policy
头部字段,约束浏览器的加载行为:
Content-Security-Policy: script-src 'self'; style-src cdn.example.org third-party.org; child-src https:
- HTML 中使用
meta
标签,约束浏览器的加载行为:
<meta http-equiv="Content-Security-Policy" content="script-src 'self'; style-src cdn.example.org third-party.org; child-src https:">
它们的形式不同,但作用都是一样的:
script-src
:设置只允许加载哪些来源的脚本,设置为self
代表只能加载本域名的脚本。注意它会禁止内联脚本的事件监听,比如前面例子的onerror
,使用的时候会报错提示违反了 CSPstyle-src
:设置只允许加载哪些来源的样式文件,这里只能加载cdn.example.org
和third-party.org
的child-src
:设置为https
表示必须使用https
去加载iframe
4)HttpOnly
前面说过,黑客可以注入脚本窃取用户的 Cookie,这本质上是因为可以通过 document.cookie
去访问 Cookie,因此服务端可以在给客户端响应的 Set-Cookie
头部字段,声明一个 HttpOnly
来禁止通过脚本获取 Cookie。
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly
CSRF
案例
CSRF 即跨站请求伪造,黑客利用请求会携带 Cookie 的特点,冒充用户身份向正常网站发出请求,执行某些非法操作。它的作用过程大概是这样的:
- 用户登录
http://article.com
,服务器验证通过,返回 Cookie 给浏览器保存 - 假设 Cookie 没过期,这期间黑客诱导用户访问恶意网站
http://evil.com
,这个网站中有这么一段代码:
<img src="http://article/delete?id=1">
- 那么恶意网站就会向
http://article.com
发起一个携带 Cookie 的请求,服务端这边验证没问题,就会把 id 为 1 的文章给删除了
这里黑客之所以可以发起 CSRF 攻击,有下面几个原因:
- 用户:登录了正常网站且没有登出(Cookie 有效),之后访问了恶意网站
- 黑客:知道执行请求的 URL 和所有的参数
- 服务端:只使用 Cookie 进行权限验证,没有任何针对 CSRF 的防御措施
img
是支持跨域请求的。其实黑客也可以直接发送一个 AJAX 请求,不过由于同源策略和 CORS 的限制,http://evil.com
是无法向不同源的http://article.com
发送请求的,所以黑客使用的是天然可以跨域的img
标签
XSS + CSRF
上面的例子属于利用 Cookie 而不是窃取 Cookie,实际上,黑客可以先使用 XSS 拿到用户的 Cookie,接着再使用 CSRF 伪造发送请求。
防御措施
要制定防御措施,可以从 CSRF 产生的原因入手:
- CSRF 大多来自第三方网站,若服务端能知道请求是谁发出的,并相应进行限制,那么可以在一定程度上规避攻击。与此相关的有
SameSite
属性、Origin
头部字段、Referer
头部字段 - CSRF 发生的关键在于第三方网站也能携带 Cookie 发送请求,导致服务端并不知道请求来自恶意网站还是正常用户。那么,我们可以让正常用户发送请求时携带一个恶意网站无法获取到的 token,服务端通过校验请求是否携带正确的 token,来把正常的请求和攻击的请求区分开,也可以防范 CSRF 攻击。与此相关的就是
CSRF token
。
同站限制 —— SameSite
- 第一方 Cookie:在
http://bank.com
下对http://bank.com/xxx
发起请求,那么携带的 Cookie 是第一方 Cookie(由第一方携带的);- 第三方 Cookie:在
http://evil.com
下对http://bank.com/xxx
发起请求,携带的则是第三方 Cookie(由第三方携带的)。
如果站点当初响应返回的 Set-Cookie
声明了 SameSite
属性,那么该 Cookie 就会成为一个同站 Cookie,这样的 Cookie 是不能作为第三方 Cookie 的 —— 换句话说,声明 SameSite
可以避免在 http://evil.com
下对 http://bank.com/xxx
发起请求时携带 Cookie 过去,因此可以规避 CSRF 攻击。
1)Set-Cookie: SameSite = Strict
:
这是最严格的模式,声明之后 Cookie 将不会在任何跨站请求中携带,也即完全禁用了第三方 Cookie,因此可以完全阻止 CSRF 攻击。但缺点是用户体验比较差 —— 比如当前网页有一个跳转到目标网站的链接,点击进入后往往已经是登录状态了,这是因为当前网页向目标网站发起的请求中携带了目标网站的 Cookie,而现在如果彻底禁用第三方 Cookie,就无法维持这个登录状态了,进入目标网站后需要重新登录。
2)Set-Cookie: SameSite = Lax
:
默认值。这种模式相对来说比较宽松,声明之后 Cookie 在大多数跨站请求的场景下还是不会携带的,因此保证了安全性;同时,导航到目标网站的 GET 请求是可以携带 Cookie 的,因此保证了可用性(比如说维持登录态)。具体情况如下:
请求类型 | 示例 | 正常情况 | Lax |
---|---|---|---|
链接 | <a href="..."></a> | 发送 Cookie | 发送 Cookie |
预加载 | <link rel="prerender" href="..."/> | 发送 Cookie | 发送 Cookie |
GET 表单 | <form method="GET" action="..."> | 发送 Cookie | 发送 Cookie |
POST 表单 | <form method="POST" action="..."> | 发送 Cookie | 不发送 |
iframe | <iframe src="..."></iframe> | 发送 Cookie | 不发送 |
AJAX | $.get("...") | 发送 Cookie | 不发送 |
Image | <img src="..."> | 发送 Cookie | 不发送 |
表格的前三种都属于导航到目标网站的 GET 请求,这些请求虽然跨站但是可以携带 Cookie —— 尤其是第一种情况,允许我们通过外链到达目标网站后直接处于登录状态。
3)Set-Cookie: SameSite = None; Secure
这种模式可以关闭 SameSite
属性,跨站请求中第三方 Cookie 的携带不受限制。但与此同时,必须声明 Secure
,让 Cookie 只能在 HTTPS 请求中携带。
为什么要设置 SameSite
之后再将其关闭,而不是一开始直接就不设置 SameSite
呢?因为 Chrome 默认设置 SameSite = Lax
,所以必须通过显式设置 SameSite = None
的方式将其关闭。
PS:Chrome 将在 2022 年全面禁用第三方 Cookie
同源检测 —— Origin
和 Referer
通常可以从请求报文的 Origin
或者 Referer
头部字段知道请求源,区别在于前者只给出服务器地址,而后者还会给出具体路径:
Origin: https://developer.mozilla.org
Referer: https://developer.mozilla.org/en-US/docs/Web/JavaScript
那么应该用哪一个呢?Origin
在 IE11 的 CORS 请求中不会携带,在 302 重定向的请求中也不会携带,所以更保险的是使用 Referer
—— 但即便如此也要知道,在 HTTPS 页面跳转到 HTTP 页面的时候,出于安全考虑,不会携带 Referer
。
CSRF token
恶意网站进行 CSRF 攻击的一个必要条件是知道请求格式和参数,那么,如果让请求必须携带一个只有正常用户才知道的 token 作为参数,恶意网站就无法构造完整的请求了,也就无法进行攻击。
模式一:隐藏表单域 + session:
服务端生成一个随机 CSRF token,存储在服务器的 session 中,同时下发 token 到用户的前端页面中。这里有两种情况
一是将 token 注入到每个表单的隐藏的
input
域中:
<input type="hidden" name="csrf-token" value="CIwNZNlR4XbisJF39I8yWnWX9wX4WFoz">
二是将 token 注入到 meta
标签中:
<meta name="csrf-token" content="CIwNZNlR4XbisJF39I8yWnWX9wX4WFoz">
- 前端若要发起 GET 请求,则通过 JS 获取
meta
中的 token,并作为请求 URL 的参数,形如http://test.com?csrftoken=CIwNZNlR4XbisJF39I8yWnWX9wX4WFoz
;若要发起 POST 请求,则直接提交表单即可,之前注入到表单中的 token 会自动作为请求体的参数 - 服务端拿到 GET 或者 POST 请求的 token 参数,与保存在 session 中的 token 比较,若相同则认为此次请求来自合法用户,否则认为来自恶意网站(恶意网站是拿不到 token 的,无法构造完整的请求)
模式二:隐藏表单域 + cookie:
- 服务端生成一对互相关联的 CSRF token,一个 token 通过隐藏表单域下发到用户的前端页面中,另一个 token 注入到
set-Cookie
字段中 - 前端提交表单发起 POST 请求,隐藏表单域中的 token 自动成为请求体的参数,
set-Cookie
中的 token 则被放到请求头部字段 Cookie 中 - 服务端对收到的两个 token 进行校验,校验通过则说明此次请求来自合法用户
PS:这种模式不需要服务端通过 session 维护大量的 token。虽然恶意网站还是可以在请求中携带 Cookie(内含 token),但是由于它拿不到服务端返回给用户的隐藏表单域(内含 token) ,因此它的请求参数是缺失的,实际上无法通过服务端的校验。
验证码
像删除数据这类敏感操作,如果不进行任何验证就直接执行操作,会有很大的风险。因此可以考虑使用验证码,但验证码应该只用于关键的业务节点中,滥用将会影响用户体验 —— 从这个角度来说,验证码更适合作为一个防御 CSRF 攻击的辅助手段。
ClickJacking
ClickJacking 即点击劫持,指的是劫持用户的点击行为进行某些操作。
比如说有一个恶意网站 http://evil.com
,有一个正常网站 http://funnyvideo.com
,恶意网站的网页下面是一个透明的、引用了正常网站的 iframe
。黑客诱导用户进入恶意网站并在网页中进行点击,看起来用户只是在点击恶意网站的网页,但实际上是在点击正常网站的网页。
防御措施
X-Frame-Options
实现:
上述点击劫持发生的本质原因是恶意网站可以通过 iframe
引用正常网站,如果我们设法禁止通过 iframe
去引用正常网站,或者限制只有某些信任网站可以通过 iframe
引用正常网站,那么就可以规避点击劫持。响应头部字段 X-Frame-Options
就是来做这个事的,它可以设置下面的值:
deny
:禁止任何网站通过iframe
引用正常网站sameorigin
:只允许同源网站通过iframe
引用正常网站allow-from
:只允许特定网站通过iframe
引用正常网站,比如allow-from http://test.com
,就表示http://test.com
是受信任的,可以引用正常网站
JS 实现:
对于某些不支持设置 X-Frame-Options
头部字段的旧浏览器,可以使用 js 作为一种 callback 的方案。
- 当 A 网站通过
iframe
引用 B 网站的时候,B 网站可以通过self
获取自身window
对象,通过top
获取 A 网站的window
对象,因此 B 网站可以使用top == self
判断自身是否被其它网站通过iframe
引用。 - 同时,它还可以通过
top.location.href
获取引用自己的网站的 URL,据此可以通过模式匹配实现网站过滤,只允许信任的网站引用自己。
if(top != self){
const style = document.createElement('style')
style.innerHTML = 'html{display:none!important;}'
document.head.appendChild(style)
top.location = self.location
}
上面的代码处理方式比较粗糙,如果 top
不等于 self
,说明有其它网站引用了自己,那么就隐藏自身的所有内容,同时让第三方网站跳转到自身。当然,可以修改代码实现类似于 X-Frame-Options
各个参数的效果。
中间人攻击
中间人攻击(Man-in-middle Attack)指的是,攻击者充当一个中间人的角色,与通信的两端分别创建独立的联系,对传输的数据进行劫持和篡改。整个会话被中间人操控,但通信的两端都以为是在和对方通信。中间人攻击发生的原因在于通信双方没有采用数字签名、数字证书等手段验证对方身份。
以前面讲过的混合加密过程为例,如果发生中间人攻击,过程大概如下:
- 客户端发送请求,请求获取服务端的公钥。中间人拦截请求,并将请求转发给服务端
- 服务端收到请求,生成一对公钥(服)和私钥(服),私钥(服)自己保管,公钥(服)发给中间人(服务端以为此时的中间人是客户端)
- 中间人拿到公钥(服)。同时生成自己的一对公钥(中)和私钥(中),冒充服务端将公钥(中)发给客户端
- 客户端收到公钥(中),生成会话密钥(客),用公钥(中)加密会话密钥(客),然后发给中间人(客户端以为此时的中间人是服务端)
- 中间人用私钥(中)解密,得到会话密钥(客)。同时生成自己的会话密钥(中),用公钥(服)加密之后发给服务端
- 服务端收到,用私钥(服)解密,拿到会话密钥(中),以为这是客户端发来的会话密钥(客),于是用这个会话密钥加密数据 XXX,发送给中间人
- 中间人收到,用会话密钥(中)进行解密,拿到数据 XXX 后进行篡改,得到数据 YYY。接着用会话密钥(客)对数据 YYY 进行加密,发送给客户端
- 客户端收到数据 YYY,用会话密钥(客)加密数据 ZZZ,发给中间人
- 中间人收到,用会话密钥(客)进行解密,拿到数据 ZZZ 后进行篡改,得到数据 WWW。接着用会话密钥(中)对数据 WWW 进行加密,发送给服务端
- 服务端收到,用会话密钥(中)解密,拿到数据 WWW
- ……
可以看到,中间人在客户端面前表现为服务端,在服务端面前又表现为客户端,两边来回劫持和篡改数据。下面的图更加清晰地展示了整个过程:
DNS 污染和劫持
DNS 劫持
DNS 劫持指的是劫持 DNS 服务器,获得对于某个域名的解析记录控制权,进而修改该域名的解析结果,返回一个错误的 IP 地址给客户端。DNS 劫持篡改的是 DNS 服务器上的数据,会导致用户无法访问某个网站,或者访问了一个假的克隆网站,从而导致个人信息泄露等。
案例:访问谷歌但是打开的是百度
解决方案:既然问题是出在 DNS 服务器,那么可以考虑手动更换 DNS 服务器为公共 DNS
DNS 污染
DNS 污染属于 DNS 缓存投毒攻击,它把自己伪装成 DNS 服务器,将用户访问的域名指向不正确的 IP 地址然后返回。
案例: GFW 导致无法访问国外网站
**解决方案:**VPN 等
HTTP 劫持
发起 HTTP 劫持的可能是第三方运营商、局域网或者免费公共 Wi-Fi 等,发生的原因在于流量必须经过运营商、局域网、Wi-Fi 等,而 HTTP 本身又是明文传输的,这就给了它们对数据进行劫持和篡改的机会。
我们有时候在浏览某些网站的时候,经常会看到右下角有弹窗广告,实际上这不一定是网站本身投放的广告,往往是运营商进行 HTTP 劫持之后投放的。解决的方法也很简单,就是使用加密的 HTTPS。